-
Notifications
You must be signed in to change notification settings - Fork 13.4k
fix(select, action-sheet): use radio role for options #30769
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
ShaneK
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good to me! Extremely minor grammatical nits, feel free to ignore if you don't wanna run CI just for them 🤷♂️
Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
ShaneK
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good, just a couple of minor things
| @Watch('buttons') | ||
| buttonsChanged() { | ||
| // Initialize activeRadioId when buttons change | ||
| if (this.hasRadioButtons) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My only concern here is this.hasRadioButtons is only set in connectedCallback, I believe this means if buttons are set dynamically this value may become stale and this process may not trigger when it's supposed to. That's sort of an edge case for many, but it may be worth re-computing this.hasRadioButtons here, if you feel like this could be a concern. Up to you!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Co-authored-by: Shane <shane@shanessite.net>
brandyscarney
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall this looks good, just one concern on the role!
|
|
||
| return { | ||
| role: isOptionSelected(selectValue, value, this.compareWith) ? 'selected' : '', | ||
| role: 'radio', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why did you decide to pass role here instead of providing it as an htmlAttribute?
I'm worried this might introduce a breaking change.
For example, if anyone is relying on the role being returned as selected when clicking it and handling it through the following event:
window.addEventListener('ionActionSheetDidDismiss', function (e) {
console.log('didDismiss', e.detail);
});this would no longer work as expected.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While it's technically possible, relying on the role from the action sheet closing event is a bit of a weird way to do things. The best practice is to just use the <ion-select>'s own ionChange event. That's why I decided to fix the role, especially because the dismissal event would only pass back 'selected' if the user happened to click on an option that was already chosen.
Example:
- No selection has occurred
- User clicks on "apple"
e.detail.role=""- User clicks on "apple" again
e.detail.role="selected"
| onKeydown(ev: KeyboardEvent) { | ||
| // Only handle keyboard navigation if we have radio buttons | ||
| if (!this.hasRadioButtons || !this.presented) { | ||
| return; | ||
| } | ||
|
|
||
| const target = ev.target as HTMLElement; | ||
|
|
||
| // Ignore if the target element is not within the action sheet or not a radio button | ||
| if ( | ||
| !this.el.contains(target) || | ||
| !target.classList.contains('action-sheet-button') || | ||
| target.getAttribute('role') !== 'radio' | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| // Get all radio button elements and filter out disabled ones | ||
| const radios = Array.from(this.el.querySelectorAll('.action-sheet-button[role="radio"]')).filter( | ||
| (el) => !(el as HTMLButtonElement).disabled | ||
| ) as HTMLButtonElement[]; | ||
|
|
||
| const currentIndex = radios.findIndex((radio) => radio.id === target.id); | ||
| if (currentIndex === -1) { | ||
| return; | ||
| } | ||
|
|
||
| let nextEl: HTMLButtonElement | undefined; | ||
|
|
||
| if (['ArrowDown', 'ArrowRight'].includes(ev.key)) { | ||
| ev.preventDefault(); | ||
| ev.stopPropagation(); | ||
|
|
||
| nextEl = currentIndex === radios.length - 1 ? radios[0] : radios[currentIndex + 1]; | ||
| } else if (['ArrowUp', 'ArrowLeft'].includes(ev.key)) { | ||
| ev.preventDefault(); | ||
| ev.stopPropagation(); | ||
|
|
||
| nextEl = currentIndex === 0 ? radios[radios.length - 1] : radios[currentIndex - 1]; | ||
| } else if (ev.key === ' ' || ev.key === 'Enter') { | ||
| ev.preventDefault(); | ||
| ev.stopPropagation(); | ||
|
|
||
| const allButtons = this.getButtons(); | ||
| const radioButtons = this.getRadioButtons(); | ||
| const buttonIndex = radioButtons.findIndex((b) => { | ||
| const buttonId = this.getButtonId(b, allButtons.indexOf(b)); | ||
| return buttonId === target.id; | ||
| }); | ||
|
|
||
| if (buttonIndex !== -1) { | ||
| this.selectRadioButton(radioButtons[buttonIndex]); | ||
| this.buttonClick(radioButtons[buttonIndex]); | ||
| } | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| // Focus the next radio button | ||
| if (nextEl) { | ||
| const allButtons = this.getButtons(); | ||
| const radioButtons = this.getRadioButtons(); | ||
|
|
||
| const buttonIndex = radioButtons.findIndex((b) => { | ||
| const buttonId = this.getButtonId(b, allButtons.indexOf(b)); | ||
| return buttonId === nextEl?.id; | ||
| }); | ||
|
|
||
| if (buttonIndex !== -1) { | ||
| this.selectRadioButton(radioButtons[buttonIndex]); | ||
| nextEl.focus(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| onKeydown(ev: KeyboardEvent) { | |
| // Only handle keyboard navigation if we have radio buttons | |
| if (!this.hasRadioButtons || !this.presented) { | |
| return; | |
| } | |
| const target = ev.target as HTMLElement; | |
| // Ignore if the target element is not within the action sheet or not a radio button | |
| if ( | |
| !this.el.contains(target) || | |
| !target.classList.contains('action-sheet-button') || | |
| target.getAttribute('role') !== 'radio' | |
| ) { | |
| return; | |
| } | |
| // Get all radio button elements and filter out disabled ones | |
| const radios = Array.from(this.el.querySelectorAll('.action-sheet-button[role="radio"]')).filter( | |
| (el) => !(el as HTMLButtonElement).disabled | |
| ) as HTMLButtonElement[]; | |
| const currentIndex = radios.findIndex((radio) => radio.id === target.id); | |
| if (currentIndex === -1) { | |
| return; | |
| } | |
| let nextEl: HTMLButtonElement | undefined; | |
| if (['ArrowDown', 'ArrowRight'].includes(ev.key)) { | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| nextEl = currentIndex === radios.length - 1 ? radios[0] : radios[currentIndex + 1]; | |
| } else if (['ArrowUp', 'ArrowLeft'].includes(ev.key)) { | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| nextEl = currentIndex === 0 ? radios[radios.length - 1] : radios[currentIndex - 1]; | |
| } else if (ev.key === ' ' || ev.key === 'Enter') { | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| const allButtons = this.getButtons(); | |
| const radioButtons = this.getRadioButtons(); | |
| const buttonIndex = radioButtons.findIndex((b) => { | |
| const buttonId = this.getButtonId(b, allButtons.indexOf(b)); | |
| return buttonId === target.id; | |
| }); | |
| if (buttonIndex !== -1) { | |
| this.selectRadioButton(radioButtons[buttonIndex]); | |
| this.buttonClick(radioButtons[buttonIndex]); | |
| } | |
| return; | |
| } | |
| // Focus the next radio button | |
| if (nextEl) { | |
| const allButtons = this.getButtons(); | |
| const radioButtons = this.getRadioButtons(); | |
| const buttonIndex = radioButtons.findIndex((b) => { | |
| const buttonId = this.getButtonId(b, allButtons.indexOf(b)); | |
| return buttonId === nextEl?.id; | |
| }); | |
| if (buttonIndex !== -1) { | |
| this.selectRadioButton(radioButtons[buttonIndex]); | |
| nextEl.focus(); | |
| } | |
| } | |
| } | |
| onKeydown(ev: KeyboardEvent) { | |
| // Only handle keyboard navigation if we have radio buttons | |
| if (!this.hasRadioButtons || !this.presented) { | |
| return; | |
| } | |
| const target = ev.target as HTMLElement; | |
| // Ignore if the target element is not within the action sheet or not a radio button | |
| if ( | |
| !this.el.contains(target) || | |
| !target.classList.contains('action-sheet-button') || | |
| target.getAttribute('role') !== 'radio' | |
| ) { | |
| return; | |
| } | |
| // Get all radio button elements and filter out disabled ones | |
| const radios = Array.from(this.el.querySelectorAll('.action-sheet-button[role="radio"]')).filter( | |
| (el) => !(el as HTMLButtonElement).disabled | |
| ) as HTMLButtonElement[]; | |
| const currentIndex = radios.findIndex((radio) => radio.id === target.id); | |
| if (currentIndex === -1) { | |
| return; | |
| } | |
| const allButtons = this.getButtons(); | |
| const radioButtons = this.getRadioButtons(); | |
| // Build a map of button element IDs to their ActionSheetButton config objects. | |
| // This allows us to quickly look up which button config corresponds to a DOM element | |
| // when handling keyboard navigation (e.g., when user presses Space/Enter or arrow keys). | |
| // The key is the ID that was set on the DOM element during render, and the value is | |
| // the ActionSheetButton config that contains the handler and other properties. | |
| const buttonIdMap = new Map<string, ActionSheetButton>(); | |
| radioButtons.forEach((b) => { | |
| const allIndex = allButtons.indexOf(b); | |
| const buttonId = this.getButtonId(b, allIndex); | |
| buttonIdMap.set(buttonId, b); | |
| }); | |
| let nextEl: HTMLButtonElement | undefined; | |
| if (['ArrowDown', 'ArrowRight'].includes(ev.key)) { | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| nextEl = currentIndex === radios.length - 1 ? radios[0] : radios[currentIndex + 1]; | |
| } else if (['ArrowUp', 'ArrowLeft'].includes(ev.key)) { | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| nextEl = currentIndex === 0 ? radios[radios.length - 1] : radios[currentIndex - 1]; | |
| } else if (ev.key === ' ' || ev.key === 'Enter') { | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| const button = buttonIdMap.get(target.id); | |
| if (button) { | |
| this.selectRadioButton(button); | |
| this.buttonClick(button); | |
| } | |
| return; | |
| } | |
| // Focus the next radio button | |
| if (nextEl) { | |
| const button = buttonIdMap.get(nextEl.id); | |
| if (button) { | |
| this.selectRadioButton(button); | |
| nextEl.focus(); | |
| } | |
| } | |
| } |
Simplify to avoid querying the buttons multiple times. You may want to make this change yourself instead of accepting mine as I probably messed up spacing when I pasted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
Issue number: internal
What is the current behavior?
The screen reader does not announce when an option is selected within the action sheet interface. This is because the action sheet uses standard buttons, which do not support a detectable selected state via native properties or ARIA attributes like
aria-checkedoraria-selected, creating an inconsistent user experience across different interface types.What is the new behavior?
role="radio"Does this introduce a breaking change?
Other information
Basic